Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit ca046eddf2b741fb113fc6bf4503f81e06932121


Parents : b0333ff
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-27T16:09:02-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-27T16:09:02-05:00

feat(widget): Glance error UI, documented resource bridge, and widget tests (#6473)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 111228bb01..d48f35c560 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -143,7 +143,6 @@ jobs:
':core:di',
':core:nfc',
':core:resources',
- ':feature:widget',
}
shards = check.split('# ── Sharded Unit Tests')[1].split('# ── Android Build')[0]

diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 3c00e5cab7..71f373de7d 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -332,7 +332,8 @@ jobs:
# shard-core: remaining core:* KMP module tests (allTests)
# shard-feature: feature:* KMP module tests + :core:service
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp,
- # core:barcode) + :core:database + :core:network
+ # core:barcode, feature:car, feature:widget)
+ # + :core:database + :core:network
test-shards:
runs-on: ubuntu-24.04
permissions:
@@ -410,6 +411,7 @@ jobs:
:core:network:allTests
:feature:car:testFdroidDebugUnitTest
:feature:car:testGoogleDebugUnitTest
+ :feature:widget:testDebugUnitTest
kover: >-
:androidApp:koverXmlReportFdroidDebug
:androidApp:koverXmlReportGoogleDebug
@@ -420,6 +422,7 @@ jobs:
:core:network:koverXmlReport
:feature:car:koverXmlReportFdroidDebug
:feature:car:koverXmlReportGoogleDebug
+ :feature:widget:koverXmlReportDebug
steps:
- name: Checkout code

diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts
index d339198c50..73c6fbc6d1 100644
--- a/feature/widget/build.gradle.kts
+++ b/feature/widget/build.gradle.kts
@@ -26,6 +26,10 @@ android {
resourcePrefix = "widget_"
defaultConfig { minSdk = 26 }
+
+ // Glance unit tests resolve Compose-resources strings through a real Context, so the merged
+ // Android resources and assets must be on the Robolectric test classpath.
+ testOptions { unitTests { isIncludeAndroidResources = true } }
}
dependencies {
@@ -42,4 +46,14 @@ dependencies {
implementation(libs.compose.multiplatform.resources)
implementation(libs.kermit)
+
+ testImplementation(kotlin("test-junit"))
+ testImplementation(libs.androidx.glance.testing)
+ testImplementation(libs.androidx.glance.appwidget.testing)
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.robolectric)
+ // Robolectric's runner is JUnit 4; configureTestOptions() turns on useJUnitPlatform() for this
+ // task, so the vintage engine is what actually discovers and runs these tests.
+ testRuntimeOnly(libs.junit.vintage.engine)
}

diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
index d37194bd1f..1b22675ed0 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt
@@ -17,16 +17,14 @@
package org.meshtastic.feature.widget
import android.annotation.SuppressLint
+import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
+import android.widget.RemoteViews
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -64,6 +62,7 @@ import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
+import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -77,6 +76,7 @@ import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.local_stats_bad
import org.meshtastic.core.resources.local_stats_diagnostics_prefix
import org.meshtastic.core.resources.local_stats_dropped
@@ -90,6 +90,7 @@ import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
+import org.meshtastic.core.resources.unknown_error
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
@@ -121,14 +122,31 @@ class LocalStatsWidget :
provideContent { WidgetContent(stateToRender) }
}
+ /**
+ * Renders our own last-resort UI when the Glance composition throws, replacing Glance's default "Can't show
+ * content" layout, and logs the failure so a broken widget stops being silent.
+ *
+ * Overriding this is the only option available: Glance's `errorUiLayout` constructor parameter is `internal` in
+ * 1.2.0-rc01, so the approach shown in the official error-handling guide does not compile from outside the library.
+ * Note that overriding this method means `errorUiLayout` is never read at all.
+ *
+ * The message comes from the Crowdin-managed Compose-resources catalog via the project's blocking [getString], so
+ * it stays translated even though a `RemoteViews` layout cannot reference Compose resources itself.
+ */
+ override fun onCompositionError(context: Context, glanceId: GlanceId, appWidgetId: Int, throwable: Throwable) {
+ Logger.e(throwable) { "LocalStatsWidget composition failed; rendering error UI" }
+ val errorViews =
+ RemoteViews(context.packageName, R.layout.widget_composition_error).apply {
+ setTextViewText(R.id.widget_composition_error_text, getString(Res.string.unknown_error))
+ }
+ AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, errorViews)
+ }
+
@Composable
internal fun WidgetContent(state: LocalStatsWidgetUiState) {
val context = LocalContext.current
- CompositionLocalProvider(
- androidx.compose.ui.platform.LocalContext provides context,
- LocalConfiguration provides context.resources.configuration,
- LocalDensity provides Density(context.resources.displayMetrics.density),
- ) {
+ // Mandatory: without this bridge every stringResource call below throws. See WidgetResourceEnvironment.
+ WidgetResourceEnvironment {
GlanceTheme {
Scaffold(
titleBar = {

diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetResourceEnvironment.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetResourceEnvironment.kt
new file mode 100644
index 0000000000..8dad2653ea
--- /dev/null
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/WidgetResourceEnvironment.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.widget
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+import androidx.glance.LocalContext
+
+/**
+ * Makes Compose Multiplatform resources usable inside a Glance composition.
+ *
+ * Glance runs its own composition with its own set of composition locals ([androidx.glance.LocalContext],
+ * [androidx.glance.LocalSize], ...). It deliberately does *not* provide the `androidx.compose.ui` locals, because a
+ * Glance tree is translated to `RemoteViews` rather than laid out and drawn by Compose UI.
+ *
+ * Compose Multiplatform's `stringResource` does not know that. On Android it reads
+ * [androidx.compose.ui.platform.LocalContext] to find the asset loader, plus [LocalConfiguration] to pick the locale
+ * and [LocalDensity] to pick density-qualified resources. None of those are present in a Glance composition, so any
+ * `stringResource` call inside a widget throws `CompositionLocal ... not present` unless we bridge them across.
+ *
+ * That is what this composable does, and it is the only reason it exists. Wrapping widget content in it is therefore
+ * mandatory, not optional: the widget is fully translated via Crowdin (`core/resources`), so essentially every visible
+ * string depends on this bridge.
+ *
+ * Two consequences worth knowing before changing this:
+ * - The bridge is a workaround, not a documented Glance pattern. If Compose Multiplatform starts reading another
+ * composition local when resolving resources, every string in the widget begins throwing at runtime. That failure is
+ * caught by [LocalStatsWidget.onCompositionError] rather than crashing the app, and `LocalStatsWidgetContentTest`
+ * fails in CI if resolution breaks.
+ * - [LocalDensity] is derived from the display metrics rather than from Glance's `LocalSize`. Glance sizing is
+ * expressed in `dp` and converted during translation, so density only affects which resource qualifier is chosen here
+ * β€” it must not be used to compute widget layout.
+ */
+@Composable
+internal fun WidgetResourceEnvironment(content: @Composable () -> Unit) {
+ val context = LocalContext.current
+ CompositionLocalProvider(
+ androidx.compose.ui.platform.LocalContext provides context,
+ LocalConfiguration provides context.resources.configuration,
+ LocalDensity provides Density(context.resources.displayMetrics.density),
+ content = content,
+ )
+}

diff --git a/feature/widget/src/main/res/drawable/widget_ic_refresh.xml b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml
index 3f20873d94..ac9cb9c285 100644
--- a/feature/widget/src/main/res/drawable/widget_ic_refresh.xml
+++ b/feature/widget/src/main/res/drawable/widget_ic_refresh.xml
@@ -20,7 +20,7 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
- android:tint="?attr/colorControlNormal">
+ android:tint="#FFFFFF">
<path
android:fillColor="@android:color/white"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>

diff --git a/feature/widget/src/main/res/layout/widget_composition_error.xml b/feature/widget/src/main/res/layout/widget_composition_error.xml
new file mode 100644
index 0000000000..0134de984e
--- /dev/null
+++ b/feature/widget/src/main/res/layout/widget_composition_error.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (c) 2026 Meshtastic LLC
+ ~
+ ~ This program is free software: you can redistribute it and/or modify
+ ~ it under the terms of the GNU General Public License as published by
+ ~ the Free Software Foundation, either version 3 of the License, or
+ ~ (at your option) any later version.
+ ~
+ ~ This program is distributed in the hope that it will be useful,
+ ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ ~ GNU General Public License for more details.
+ ~
+ ~ You should have received a copy of the GNU General Public License
+ ~ along with this program. If not, see <https://www.gnu.org/licenses/>.
+ -->
+
+<!--
+ Last-resort UI for LocalStatsWidget, inflated by LocalStatsWidget.onCompositionError when the Glance
+ composition throws. It replaces Glance's default "Can't show content" layout.
+
+ This is a RemoteViews layout, so it is deliberately plain: no Compose, no theming, no click targets. The text is
+ left empty here and set at runtime via setTextViewText, because the user-facing string lives in the
+ Crowdin-managed Compose-resources catalog (core/resources) rather than in Android resources. Hardcoding a string
+ here would ship it untranslated.
+
+ textColorPrimary resolves against the host launcher's theme, which keeps the message legible in both light and
+ dark without us guessing at a background colour.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:gravity="center"
+ android:padding="16dp">
+
+ <ImageView
+ android:layout_width="32dp"
+ android:layout_height="32dp"
+ android:src="@drawable/widget_app_icon"
+ android:contentDescription="@null" />
+
+ <TextView
+ android:id="@+id/widget_composition_error_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:gravity="center"
+ android:maxLines="2"
+ android:textColor="?android:attr/textColorPrimary"
+ android:textSize="12sp" />
+
+</LinearLayout>

diff --git a/androidApp/src/main/res/layout/widget_local_stats_preview.xml b/feature/widget/src/main/res/layout/widget_local_stats_preview.xml
similarity index 69%
rename from androidApp/src/main/res/layout/widget_local_stats_preview.xml
rename to feature/widget/src/main/res/layout/widget_local_stats_preview.xml
index 49092eaa7f..d365d5a7c2 100644
--- a/androidApp/src/main/res/layout/widget_local_stats_preview.xml
+++ b/feature/widget/src/main/res/layout/widget_local_stats_preview.xml
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
- ~ Copyright (c) 2025 Meshtastic LLC
+ ~ Copyright (c) 2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
@@ -16,6 +16,14 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
+<!--
+ Widget-picker preview (android:previewLayout in widget_local_stats_info.xml), used on API levels that predate the
+ generated previews LocalStatsWidget.providePreview supplies.
+
+ Lives in this module rather than androidApp because the provider info XML that references it does. Keeping it in
+ androidApp left this module's resources unresolvable on their own, which broke resource linking as soon as the
+ module gained unit tests.
+-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
@@ -27,14 +35,14 @@
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
- android:src="@drawable/ic_launcher_foreground"
+ android:src="@drawable/widget_app_icon"
android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
- android:text="Meshtastic Stats"
+ android:text="@string/widget_local_stats_label"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:textSize="14sp" />

diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetContentTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetContentTest.kt
new file mode 100644
index 0000000000..f14ae89895
--- /dev/null
+++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetContentTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.widget
+
+import android.app.Application
+import android.content.Context
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest
+import androidx.glance.testing.unit.hasContentDescriptionEqualTo
+import androidx.glance.testing.unit.hasText
+import androidx.test.core.app.ApplicationProvider
+import org.junit.runner.RunWith
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.disconnected
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.refresh
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Unit tests for [LocalStatsWidget.WidgetContent] using Glance's own test harness.
+ *
+ * The harness does not inflate views, so these assert on the composed Glance tree rather than pixels. Robolectric is
+ * required because the content resolves Compose Multiplatform string resources, which needs a real [Context].
+ *
+ * The load-bearing assertion across all of these is that *any* string renders at all: every one goes through the
+ * [WidgetResourceEnvironment] bridge, which hand-provides the `androidx.compose.ui` composition locals that Glance
+ * omits. If that bridge breaks β€” most likely because Compose Multiplatform starts reading another local β€” resolution
+ * throws and these tests fail here instead of shipping a blank widget.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [34])
+class LocalStatsWidgetContentTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ // Resolved here, at construction, rather than inside the test bodies: getString() wraps runBlocking, and
+ // runGlanceAppWidgetUnitTest runs its block inside runTest, where a nested runBlocking risks deadlocking.
+ private val disconnectedLabel: String = getString(Res.string.disconnected)
+ private val refreshLabel: String = getString(Res.string.refresh)
+
+ @Test
+ fun `connected content renders node identity and footer counts`() =
+ runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) {
+ setAppWidgetSize(BIG_SQUARE)
+ setContext(context)
+
+ provideComposable { LocalStatsWidget().WidgetContent(createMockWidgetState()) }
+
+ // Values straight from createMockWidgetState(): short name, battery, and onlineNodes/totalNodes.
+ onNode(hasText("ME")).assertExists()
+ onNode(hasText("85%")).assertExists()
+ onNode(hasText("2/3")).assertExists()
+ }
+
+ @Test
+ fun `disconnected content renders the translated disconnected status`() =
+ runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) {
+ setAppWidgetSize(BIG_SQUARE)
+ setContext(context)
+
+ provideComposable {
+ LocalStatsWidget()
+ .WidgetContent(
+ LocalStatsWidgetUiState(connectionState = ConnectionState.Disconnected, showContent = false),
+ )
+ }
+
+ // Proves the Crowdin-managed string resolved through the WidgetResourceEnvironment bridge.
+ onNode(hasText(disconnectedLabel)).assertExists()
+ }
+
+ @Test
+ fun `refresh affordance is present and labelled for accessibility`() =
+ runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) {
+ setAppWidgetSize(BIG_SQUARE)
+ setContext(context)
+
+ provideComposable { LocalStatsWidget().WidgetContent(createMockWidgetState()) }
+
+ // Glance's unit-test filters have no matcher for actionRunCallback, so this pins the button's
+ // presence and its content description rather than the callback class it dispatches to.
+ onNode(hasContentDescriptionEqualTo(refreshLabel)).assertExists()
+ }
+
+ @Test
+ fun `narrow layout still renders content rather than collapsing`() =
+ runGlanceAppWidgetUnitTest(timeout = TEST_TIMEOUT) {
+ // Smallest entry in LocalStatsWidget's SizeMode.Responsive set, where isNarrow/isShort both kick in.
+ setAppWidgetSize(SMALL_SQUARE)
+ setContext(context)
+
+ provideComposable { LocalStatsWidget().WidgetContent(createMockWidgetState()) }
+
+ onNode(hasText("ME")).assertExists()
+ }
+
+ private companion object {
+ val SMALL_SQUARE = DpSize(100.dp, 100.dp)
+ val BIG_SQUARE = DpSize(250.dp, 250.dp)
+
+ /**
+ * Well above Glance's 2-second `DEFAULT_TIMEOUT`, which is not a safe budget here. Booting Robolectric and
+ * loading Compose Multiplatform resources comfortably exceeds two seconds whenever the machine is busy, and
+ * `configureTestOptions()` sets `maxParallelForks` to every available processor in CI. The default timeout was
+ * already observed failing one of these tests with `UncompletedCoroutinesError` during a full baseline run
+ * while passing in isolation β€” the classic signature of a too-tight budget rather than a hung test.
+ */
+ val TEST_TIMEOUT = 30.seconds
+ }
+}

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 10763725b6..f68ac1ad8d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -133,8 +133,10 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "d
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
androidx-glance-appwidget-preview = { module = "androidx.glance:glance-appwidget-preview", version.ref = "glance" }
+androidx-glance-appwidget-testing = { module = "androidx.glance:glance-appwidget-testing", version.ref = "glance" }
androidx-glance-preview = { module = "androidx.glance:glance-preview", version.ref = "glance" }
androidx-glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
+androidx-glance-testing = { module = "androidx.glance:glance-testing", version.ref = "glance" }
# Android-only lifecycle β€” no KMP equivalent (use only in androidMain / androidHostTest)
# lifecycle-runtime-ktx dropped: KTX extensions merged into lifecycle-runtime since 2.8.0;
# use jetbrains-lifecycle-runtime (JB KMP fork) instead.

Served by rngit 1.5.2 - Generated in 0.13s